Skip to content

Fail a rolling restart job that did not restart every node - #3498

Merged
kriszyp merged 5 commits into
mainfrom
fix/rolling-restart-job-outcome
Oct 26, 2026
Merged

kriszyp merged 5 commits into
mainfrom
fix/rolling-restart-job-outcome

Conversation

@kriszyp

@kriszyp kriszyp commented Oct 23, 2026 •

Copy link
Copy Markdown
Member
A frantic mailroom worker traces red strings from one crossed-out envelope back to an overflowing mail tray and a dark server cabinet

⊙ Problem

A rolling restart (deploy_component with restart: "rolling", or a replicated restart_service) reported COMPLETE even when a peer was never restarted; that node kept serving old code. On the peer, a failed job insert was reported as Empty job passed to parseMessage instead of its real cause (a write-queue 503). Polling a restarting peer also leaked unhandled rejections. Reported in #3468.

🤔 Is ERROR the right outcome, rather than a new partial status?

Yes: any job can already end ERROR, so get_job callers handle it, and the per-node detail stays in result.replicated. A new status would be one more value every client must learn.

🤔 Backport to v5.3?

Recommended: the report came from a 5.3.3 cluster and the change applies there unchanged. It changes what get_job reports for a failed roll, so it is your call; the milestone is unset.

💡 Solution

⚖️ Alternatives

  • Patch the old loop in place — its setInterval poller overlapped requests and leaked rejections, so the poller needed rewriting anyway.
  • Retry inside sendOperationToNode — it forwards every operation and cannot know which are safe to repeat.
  • Keep COMPLETE and document result.replicated — a caller polling status still cannot tell a node runs old code.

🤔 Should a peer that could not be reached stop the roll?

No: a peer that returned its own error, or whose connection never opened, did not start a restart. The roll continues and the job still ends ERROR. Any other dispatch failure halts it so two peers never restart at once.

🤔 Is matching error text acceptable for the retry and halt decisions?

Errors cross replication as text plus a socket code, the only signal today; both 5.3 and current wordings match. A structured code is #3496.

🔧 Changes

result.replicated is the per-node outcome that #3058 and #3060 (deploy record and CLI exit for rolling deploys) can consume; neither is changed here, and deploy_component's own response is unchanged.

✅ Verification

Full unit gate note

test:unit:main hit its 50-minute timeout in the components suite (interrupted activation recovery), with failures in ComponentV1.test.js and componentPreparationLock.test.js. Both files pass in isolation on this branch and on main (59/59 each), and neither touches changed code.

PR QA: queued qa-pr-harper-3498-b323922 @ b323922

The PR QA scenario, ready to queue

QA-PR harper#3498 — Does a real two-node rolling restart report ERROR, with per-peer outcomes, when the peer cannot restart?

Status: pending

source:pr:3498 · harper b323922 · branch fix/rolling-restart-job-outcome · base main · [two-node harper-pro cluster built against this core]

Assumption under test: A replicated restart_service ({"operation":"restart_service","service":"http","replicated":true}) ends COMPLETE only when every peer's own restart job completed; otherwise get_job shows status: ERROR, a message naming the peer, and result.replicated with one entry per peer ({ node, message } restarted, { node, error } not). The origin classifies peer failures from the text and socket code the real replicator delivers (sendOperationToNode): a write-queue back-pressure refusal ("Outstanding write transactions have too long a queue") is retried after the other peers; "outcome there is unknown" (timeout / connection closed first) and coded socket errors other than ECONNREFUSED/ENOTFOUND/EAI_AGAIN/EHOSTUNREACH/ENETUNREACH halt the roll.

Not covered by the PR's tests: the restart tests stub server.replication.sendOperationToNode with hand-written error strings and never runs jobProcess → updateJob → get_job. Nothing exercises the real replicator's error text/code, the persisted hdb_job row on ERROR, or a real peer job insert failing.

Question: Build harper-pro with its core submodule at the PR head (git -C core fetch origin pull/3498/head && git -C core checkout FETCH_HEAD, then build). Start two nodes A and B, cluster them (add_node), confirm replication is connected. Arms, each started from A and polled with get_job on A until terminal:

  1. Baseline: both nodes healthy → expect COMPLETE, result.replicated = one { node: B, message } entry.
  2. Peer down: stop B's process entirely → expect ERROR naming B with an ECONNREFUSED-class error in result.replicated (the roll does not need to halt; there is only one peer).
  3. Peer refuses: on B, make the job-row insert fail (e.g. storage.maxTransactionQueueTime set very low on B plus a sustained write load on B, or any reproducible way to make B's hdb_job insert throw) → expect A's job to retry B (log line "was refused ... retrying after the other nodes") and end ERROR or COMPLETE consistent with whether B eventually accepted; B's log must not contain "Empty job passed to parseMessage".
  4. Peer restarts mid-poll: with a 3rd node C if cheap, restart B's workers externally while A polls B's job → no unhandledRejection lines in A's job-thread log, job still settles.
  5. In every arm, check get_job on A returns result.replicated on ERROR (persisted, not only in memory).

Oracle: arm 1 COMPLETE; arm 2 ERROR with B named in message and a { node: "B", error: ... } entry in result.replicated; arm 3 COMPLETE if B accepted a retry, otherwise ERROR the same way; no "Empty job passed to parseMessage" anywhere; zero unhandledRejection lines on A in any arm. A COMPLETE in arm 2 (or in arm 3 without B's restart), a missing result.replicated on ERROR, or an unhandled rejection is a finding.

Regression A/B: on failure, repeat with core at the PR's merge base on main (d4f9fb9); expected there: arm 2 reports COMPLETE with B's failure buried in result.replicated (the reported bug), arm 3 shows "Empty job passed to parseMessage".

Axes: replication (operation forwarding), jobs lifecycle (hdb_job persistence), restart lifecycle, mixed failure classes (refused / unreachable / dropped mid-poll); storage RocksDB.

Docs: queued docs-harper-3498.

Written by Claude Opus 5.5 (dev-agent) for @kriszyp.

Release target: v5.3 — reported on 5.3.3 in production and the same code is on v5.3; milestone left for Kris to set
Related PRs: 2 independent
Complexity: medium

🤖 Generated with Claude Code

Deferred findings

  • 0e1ac605d39a8fd2 minor concurrency — bin/restart.ts — activateEachPeer continues to next peer after outcome-unknown / connection-closed dispatch failure, allowing overlapping peer restarts — NOT FILED
  • 4deaca79938c53f6 nit reliability — bin/restart.ts — back-pressure retry keyed on message text — filed Classify a replicated operation's peer failure by status, not message text #3496
  • f59c6bbabccf3902 nit test-coverage — unitTests/server/jobs/executeJob.test.js — no regression check that a statusCode-bearing addJob error passes through executeJob unwrapped — NOT FILED
Origin — the dispatch brief this PR was written from

Fix harper#3468 on main: a replicated restart_service job (the job behind deploy_component with restart: "rolling") reports status COMPLETE even when a node was never restarted, and a failed job insert on a peer is reported as the misleading "Empty job passed to parseMessage". Address all four defects listed in the issue: (1) bin/restart.ts pushes { node, message } and continues when sendOperationToNode throws, then returns { replicated }, so the job completes normally; make the job end in ERROR naming every node that failed or was skipped, keeping the per-node result.replicated entries. (2) server/jobs/jobs.ts addJob catches the insert error and returns { success: false } with no createdJob; executeJob (server/serverHelpers/serverUtilities.ts) only checks if (result) and builds a RunnerMessage with job undefined, so parseMessage throws. Make executeJob check success/createdJob and surface the original error (e.g. the write-queue back-pressure error). (3) Retry a node whose failure is a transient back-pressure error (the 'Outstanding write transactions have too long of queue, please try again later' class) a bounded number of times, for example re-queueing it after the remaining nodes, before marking it failed. (4) The get_job poll in bin/restart.ts runs in setInterval(async () => ...) with no try/catch, producing unhandledRejection (Connection closed 1006 / socket disconnected before TLS established) while a peer restarts; handle those and keep polling.

Acceptance

Unit tests: a restart job with one peer whose operation throws ends ERROR and names that peer; a peer whose job insert fails surfaces the original insert error from executeJob, not 'Empty job passed to parseMessage'; a transient back-pressure failure that succeeds on retry leaves the job COMPLETE; a peer connection drop during the get_job poll produces no unhandled rejection. Each new test fails on unmodified main. Existing restart/deploy unit and integration suites pass. Do not change deploy_component's own response or the deploy record (harper#3058/#3060 scope). Draft PR only.

Dispatch: task harper-3468-rolling-restart-outcome · queued by kris-session · ran by claude/opus/high · worker kzyp-xps-1

Review-Coverage: authored=claude; ran=cursor-composer,gemini,codex; adjudicated=domain; declined=cursor-grok,muse; rounds=7; full=3 @ b323922

Review-Attention: read ~5m (decisions: halt-on-unclassified-socket-error, halt-on-peer-job-failure, addjob-throws, do-less-alternative, test-only-export) @ b323922

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a sequential rolling restart mechanism (restartEachPeer) that restarts peers one by one and polls for completion, halting if any peer fails. It also refactors job insertion to propagate errors directly by removing try-catch blocks in jobs.ts and serverUtilities.ts, and adds comprehensive unit tests. Feedback was provided to remove a redundant and inefficient Array.from() call on pSearchByValue() in jobs.ts.

Comment thread server/jobs/jobs.ts Resolved
kriszyp and others added 5 commits October 23, 2026 22:38
A replicated restart_service (the job behind deploy_component
restart: 'rolling') recorded a peer it could not restart and still
completed, so a caller polling get_job saw COMPLETE while that peer ran
old code. It now ends ERROR naming each peer that failed or was skipped,
with every peer's outcome kept in result.replicated.

- restartEachPeer: retries a peer that refused for write back-pressure
  after the other peers (three attempts), polls get_job sequentially and
  keeps polling through a connection drop instead of an unhandled
  rejection, bounds every request to a peer, and halts the roll at a peer
  whose restart failed or timed out.
- addJob rethrows the search/insert error it used to swallow, and
  executeJob refuses a missing createdJob, so a failed job insert
  surfaces its own error instead of "Empty job passed to parseMessage".
- jobProcess keeps a failed job's err.result.

Refs #3468

Dispatch-Task: harper-3468-rolling-restart-outcome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A dispatch the replicator reports as having an unknown outcome (a
timeout, or the connection closed before the answer) may have started
that peer's restart, so moving on could restart two peers at once. Such a
peer now halts the roll, as does an answer with no job id. A peer job in
ERROR without a message, or with a status the poller does not know, also
counts as failed, and a poll that times out names its last failure. The
executeJob test fails a real job-row encode instead of a sinon stub.

Dispatch-Task: harper-3468-rolling-restart-outcome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…the peer

Only a peer's own error answer or a connection that never opened
(ECONNREFUSED and similar) lets the roll move on; any other socket error
halts it. A poll that succeeds clears the last poll failure, so a timeout
on a reachable peer does not blame the network.

Dispatch-Task: harper-3468-rolling-restart-outcome
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pending

The session rejects requests pending at close with a code-less
'Connection closed' error, which read as a peer's own answer, so the roll
moved on while the peer could be restarting.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-0346cd78c3159a8a3a9f365e1b7c7aec
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Dispatch-Task: pr-maint-0346cd78c3159a8a3a9f365e1b7c7aec
@kriszyp
kriszyp force-pushed the fix/rolling-restart-job-outcome branch from f930897 to b323922 Compare October 24, 2026 04:45
@kriszyp
kriszyp marked this pull request as ready for review October 26, 2026 12:41
@kriszyp
kriszyp merged commit 8bb32af into main Oct 26, 2026
71 of 74 checks passed
@kriszyp
kriszyp deleted the fix/rolling-restart-job-outcome branch October 26, 2026 12:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant